Skip to content
158 linesCodeBlameRaw
1import type { ChatMessage } from "@g1t/contracts";
2import { Stack, useLocalSearchParams } from "expo-router";
3import * as WebBrowser from "expo-web-browser";
4import { SendHorizontal } from "lucide-react-native";
5import { useEffect, useMemo, useState } from "react";
6import { ActivityIndicator, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8
9import { Avatar, Badge, ErrorView, Loading, Screen, Text } from "@/components/ui";
10import { markRead, useChannelLive, useChatSidebar, useMessages, usePostMessage } from "@/lib/chat";
11import { siteUrl } from "@/lib/server";
12import { useSignedIn } from "@/lib/session";
13import { clock } from "@/lib/time";
14import { radius, space, useColors } from "@/theme";
15
16/**
17 * A conversation: its messages, newest at the bottom, live while open, and
18 * a box to write in. `channel` is a channel's id, or a channel's name when
19 * a link named it (`/acme/-/chat/general`).
20 */
21export default function Conversation() {
22 const params = useLocalSearchParams<{ channel: string; title?: string }>();
23 const { client, workspace, server } = useSignedIn();
24 const sidebar = useChatSidebar();
25 const entry = sidebar.data?.entries.find((one) => one.channel.id === params.channel || one.channel.name === params.channel);
26 // A link by name waits for the sidebar to say which channel it is.
27 const channelId = entry?.channel.id ?? (params.channel.includes("_") ? params.channel : "");
28 const title = params.title ?? (entry ? (entry.channel.kind === "dm" ? entry.title : `#${entry.channel.name}`) : "Chat");
29
30 const messages = useMessages(channelId);
31 const live = useChannelLive(channelId);
32 const post = usePostMessage(channelId);
33 const [draft, setDraft] = useState("");
34 const colors = useColors();
35 const insets = useSafeAreaInsets();
36
37 // Newest first, for a list drawn from the bottom up.
38 const newestFirst = useMemo(
39 () => (messages.data?.pages ?? []).flatMap((page) => [...page.messages].reverse()).filter((message) => !message.deleted_at),
40 [messages.data],
41 );
42 const newest = newestFirst[0]?.id;
43 useEffect(() => {
44 if (workspace && channelId && newest) void markRead(client, workspace.slug, channelId, newest);
45 }, [client, workspace, channelId, newest]);
46
47 const send = () => {
48 const body = draft.trim();
49 if (!body || post.isPending) return;
50 post.mutate(body, { onSuccess: () => setDraft("") });
51 };
52
53 return (
54 <Screen>
55 <Stack.Screen options={{ title, headerRight: () => <LiveDot status={live} /> }} />
56 <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} keyboardVerticalOffset={90}>
57 {!channelId && sidebar.isPending ? (
58 <Loading />
59 ) : messages.isPending ? (
60 <Loading />
61 ) : messages.error ? (
62 <ErrorView error={messages.error} retry={() => messages.refetch()} />
63 ) : (
64 <FlatList
65 inverted
66 data={newestFirst}
67 keyExtractor={(message) => message.id}
68 renderItem={({ item }) => <Message message={item} site={server.site} />}
69 onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
70 onEndReachedThreshold={0.5}
71 ListFooterComponent={messages.isFetchingNextPage ? <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} /> : null}
72 contentContainerStyle={{ paddingVertical: space.md }}
73 />
74 )}
75 <View style={[styles.composer, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: Math.max(insets.bottom, space.sm) }]}>
76 <TextInput
77 value={draft}
78 onChangeText={setDraft}
79 placeholder={`Message ${title}`}
80 placeholderTextColor={colors.faint}
81 multiline
82 style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]}
83 />
84 <Pressable accessibilityRole="button" accessibilityLabel="Send" onPress={send} disabled={!draft.trim() || post.isPending} style={styles.send}>
85 {post.isPending ? <ActivityIndicator color={colors.accent} /> : <SendHorizontal color={draft.trim() ? colors.accent : colors.faint} size={22} />}
86 </Pressable>
87 </View>
88 {post.error ? (
89 <Text tone="danger" size="small" style={{ paddingHorizontal: space.lg, paddingBottom: space.sm }}>
90 {post.error.message}
91 </Text>
92 ) : null}
93 </KeyboardAvoidingView>
94 </Screen>
95 );
96}
97
98function Message({ message, site }: { message: ChatMessage; site: string }) {
99 const colors = useColors();
100 const { author } = message;
101 const card = message.kind === "card" ? message.card : null;
102 return (
103 <View style={styles.message}>
104 <Avatar name={author.display_name} uri={author.avatar ? `${site}/avatars/${author.avatar}` : null} agent={author.kind === "agent"} />
105 <View style={{ flex: 1, gap: 2 }}>
106 <View style={{ flexDirection: "row", gap: space.sm, alignItems: "baseline" }}>
107 <Text style={{ fontWeight: "600" }}>{author.display_name}</Text>
108 <Text size="small" tone="faint">
109 {clock(message.created_at)}
110 {message.edited_at ? " · edited" : ""}
111 </Text>
112 </View>
113 {message.body ? <Text selectable>{message.body}</Text> : null}
114 {card ? (
115 <Pressable
116 disabled={!card.href}
117 onPress={() => card.href && WebBrowser.openBrowserAsync(siteUrl({ site, api: "" }, card.href))}
118 style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]}
119 >
120 <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
121 <Text style={{ flex: 1, fontWeight: "600" }}>{card.title}</Text>
122 {card.state ? <Badge label={card.state} /> : null}
123 </View>
124 {card.detail ? (
125 <Text size="small" tone="muted">
126 {card.detail}
127 </Text>
128 ) : null}
129 {card.body ? (
130 <Text size="small" numberOfLines={6}>
131 {card.body}
132 </Text>
133 ) : null}
134 </Pressable>
135 ) : null}
136 {message.reply_count > 0 ? (
137 <Text size="small" tone="accent">
138 {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`}
139 </Text>
140 ) : null}
141 </View>
142 </View>
143 );
144}
145
146function LiveDot({ status }: { status: "connecting" | "live" | "offline" }) {
147 const colors = useColors();
148 const color = status === "live" ? colors.success : status === "connecting" ? colors.warn : colors.faint;
149 return <View accessibilityLabel={status === "live" ? "Live" : status === "connecting" ? "Connecting" : "Offline"} style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />;
150}
151
152const styles = StyleSheet.create({
153 message: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.sm },
154 card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.xs, marginTop: space.xs },
155 composer: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth },
156 input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingVertical: space.sm, fontSize: 15 },
157 send: { width: 40, height: 40, alignItems: "center", justifyContent: "center" },
158});